Eine Kernaufgabe der Softwarearchitektur ist die Modellierung interessanter Domänen mit komplexen Regeln und Abhängigkeiten. Es gibt immer die Möglichkeit, Regeln und Zusammenhänge ad hoc in Form von Funktionen oder Methoden abzubilden. Diese Herangehensweise funktioniert, stößt aber schnell an ihre Grenzen, wenn es um Flexibilität, Erweiterbarkeit und Wartbarkeit geht. Hier kommen Kombinatormodelle ins Spiel: ein deklarativer Ansatz, der nicht nur Ihren Code sauberer und wartbarer macht, sondern auch neue Perspektiven für die Modellierung von Domänen öffnet.
In diesem Artikel erklären wir:
-
warum Kombinatormodelle eine attraktive Alternative zu klassischen Ansätzen sind,
-
wie wir von diesem Ansatz profitieren und
-
wie sich der Code durch Modellierung mit Kombinatoren klarer und einfacher erweitern lässt.
Wir zeigen das anhand eines Beispiels aus der Kreditprüfung, indem wir eine naive Implementierung Schritt für Schritt in ein Kombinatormodell refaktorisieren. Abschließend diskutieren wir, was das mit Mathe zu tun hat und warum Scala besonders gut für diesen Ansatz geeignet ist – besser als beispielsweise Java.
Die naive Implementierung
Beginnen wir mit einer einfachen Implementierung für die Kreditprüfung. Angenommen, eine Person muss folgende Bedingungen erfüllen, um einen Kredit zu erhalten:
-
Ist mindestens 18 Jahre alt und fest angestellt,
-
hat entweder ein Einkommen von mehr als 60 000 € und 10 Prozent Eigenkapital oder
-
hat ein Einkommen von mehr als 45 000 € und 25 Prozent Eigenkapital.
Ein erster Ansatz könnte so aussehen, wie es Listing 1 zeigt.
Listing 1
case class Person(age: Int,
equity: Double,
income: Double,
employed: Boolean)
def getsLoan(person: Person, loanAmount: Double): Boolean = {
val baseline = person.age >= 18 && person.employed
val highIncome = person.income >= 60000 &&
person.equity >= loanAmount * 0.1
val highEquity = person.income >= 45000 &&
person.equity >= loanAmount * 0.25
baseline && (highIncome || highEquity)
}
Dieser Code funktioniert, weist jedoch mehrere Probleme auf:
-
Fehlende Flexibilität: Es ist schwierig, neue Regeln oder Bedingungen hinzuzufügen, ohne bestehende Logik zu verändern.
-
Schwer wartbar: Die gesamte Logik ist in eine einzige Funktion eingebettet. Das macht den Code schwer lesbar und fehleranfällig.
-
Keine Wiederverwendbarkeit: Bedingungen wie „mindestens 18 Jahre alt“ oder „Mindesteinkommen“ sind nicht abstrahiert und können nicht eigenständig wiederverwendet werden.
Darauf können wir reagieren, indem wir die Implementierung der einzelnen Regeln herausziehen und kombinieren (Listing 2).
Listing 2
case class Person(age: Int, equity:
Double, income: Double,
employed: Boolean)
def ofAge(person: Person, legalAge: Int): Boolean =
person.age >= legalAge
def employed(person: Person): Boolean =
person.employed
def baseline(person: Person): Boolean =
ofAge(person, 18) && employed(person)
def highIncome(person: Person, loanAmount: Double): Boolean =
person.income >= 60000 && person.equity >= loanAmount * 0.1
def highEquity(person: Person, loanAmount: Double): Boolean =
person.income >= 45000 && person.equity >= loanAmount * 0.25
def getsLoan(person: Person, loanAmount: Double): Boolean = {
baseline(person) &&
(highIncome(person, loanAmount) || highEquity(loanAmount))
}
Dabei könnten wir es belassen. Als funktionale Programmierer:innen fällt uns aber vor allem eines negativ auf: Anstelle eines Datenmodells für die Domäne der Regelüberprüfung haben wir hier einige zusammenhanglose Funktionen. Anstatt einer Funktion, die für eine gegebene Person überprüft, ob sie alt genug ist (ofAge), hätten wir lieber einen Wert, der diese Regel darstellt. Nicht ganz klar? Dann schaffen wir Klarheit!
Mit einem Kombinatormodell können wir Regeln und deren Kombination deklarativ modellieren. Der zentrale Schritt ist folgender: Aus Funktionen, die die Einhaltung der Regeln prüfen, werden Daten, die die Regeln beschreiben. Wir bauen uns also eine deklarative Schicht (die Beschreibung der Regeln) und trennen sie von der Überprüfung.
Das Regelmodell
Zuerst definieren wir ein Regelmodell als algebraischen Datentyp. Der Typparameter A ist für den Gegenstand der Regeln, im obigen Fall werden wir Person einsetzen.
enum Rule[A] {
case Predicate(name: String, predicate: A => Boolean)
case And(left: Rule[A], right: Rule[A])
case Or(left: Rule[A], right: Rule[A])
}
Wir beschränken uns hier auf das unserer Meinung nach Wesentliche:
-
Predicate: ein Prädikat, das einen beliebigen Wert A nimmt und einen Boolean zurückliefert. Außerdem besteht Predicate noch aus einem String name, der die Regel für Menschen verständlich beschreibt (wir sehen weiter unten, warum das praktisch ist).
-
Die Vereinigung zweier Regeln mit And: Es müssen beide Regeln eingehalten werden.
-
Zwei Regeln als Alternativen mit Or: Es muss nur eine der beiden Regeln eingehalten werden.
Regeln erstellen
Mit unseren Kombinatoren ausgestattet, können wir unsere Regel von oben erneut definieren (Listing 3).
Listing 3
def incomeRule(income: Double): Rule[Person] =
Rule.Predicate(s"Income >= $income", _.income >= income)
def equityRule(equity: Double): Rule[Person] =
Rule.Predicate(s"Equity >= $equity", _.equity >= equity)
def employed: Rule[Person] =
Rule.Predicate("Employed", _.employed)
def minimumAgeRule(age: Int): Rule[Person] =
Rule.Predicate(s"Age >= $age", _.age >= age)
Um zu verstehen, was hier steht, schauen wir uns die Typen der Werte, zum Beispiel incomeRule, an: Die Einkommensregel besagt, dass die Regel erfüllt ist, wenn ein bestimmter Betrag (das Argument income) erreicht ist. Das Resultat des Funktionsaufrufs incomeRule(50000.0) ist eine Rule[Person], also eine Regel für Objekte vom Typ Person.
Zusammengesetzte Regeln
Durch Kombination der Regeln können wir jetzt auch wieder die Kreditprüfung implementieren (Listing 4).
Listing 4
def baselineRule: Rule[Person] =
Rule.And(minimumAgeRule(18), employed)
def highIncomeLowEquity(loanAmount: Double): Rule[Person] =
Rule.And(incomeRule(60000), equityRule(loanAmount * 0.1))
def highEquityLowIncome(loanAmount: Double): Rule[Person] =
Rule.And(incomeRule(45000), equityRule(loanAmount * 0.25))
def getsLoanRule(loanAmount: Double): Rule[Person] =
Rule.And(baselineRule,
Rule.Or(highIncomeLowEquity(loanAmount),
highEquityLowIncome(loanAmount)))
Auch die Kombination einfacher Regeln zu komplexeren Regeln ergibt wieder nur – Trommelwirbel – neue Regeln.
Vorteile der Modellierung mit Kombinatoren
Warum betreiben wir den Aufwand für diese Art der Modellierung? Den ersten Punkt schulden wir ohnehin noch, also fangen wir damit an: Wir trennen die Definition der Regeln von ihrer Interpretation – diese müssen wir nämlich noch nachreichen (Listing 5). Damit können wir nun konkreten Personen sagen, ob sie einen Kredit bekommen oder nicht (Listing 6). Und das führt uns zu unserem ersten und wichtigsten Punkt für eine Modellierung mit Kombinatoren.
Listing 5
def evaluate[A](rule: Rule[A], input: A): Boolean = rule match {
// Ein Prädikat wird ausgewertet,
// indem wir es auf den Input anwenden.
case Predicate(_, p) => p(input)
// Ein "Und" ist True, wenn links und rechts true sind.
case And(left, right) => left.evaluate(input) &&
right.evaluate(input)
// Ein "Oder" ist True, wenn links oder rechts true sind.
case Or(left, right) => left.evaluate(input) ||
right.evaluate(input)
}
Listing 6
// Marco, 35 Jahre alt, 30000 € Eigenkapital,
// 450000 € Einkommen, fest angestellt
val marco = Person(35, 30000.0, 45000.0, true)
// Mimi, 5 Jahre alt, kein Eigenkapital oder Einkommen, nicht angestellt
val mimi = Person(4, 0.0, 0.0, false)
evaulate(getsLoanRule(100000.0), marco) // => true (bekommt den Kredit)
evaulate(getsLoanRule(100000.0), mimi) // => false (bekommt den Kredit nicht)
Verschiedene Interpretationen
Wir wollen vielleicht nicht immer nur wissen, ob eine Person einen Kredit bekommt. Eine alternative Anforderung, die denkbar ist: Ein:e Antragsteller:in möchte gern wissen, warum er oder sie keinen Kredit bekommt. Aus unseren Funktionen vom Anfang wäre da nicht viel herauszuholen – jemand muss jeweils nachsehen, was genau nicht gepasst hat. Mit unserer neuen Modellierung ist das gar kein Problem. Da Regeln Datenstrukturen sind, können wir sie unterschiedlich interpretieren. Zum Beispiel können wir alle fehlenden Bedingungen für einen bestimmten Input anzeigen (Listing 7).
Listing 7
def missingRequirements(rule: Rule[Person], person: Person): List[String] = rule match {
case Rule.Predicate(name, predicate) =>
if predicate(person) then Nil else List(name)
case Rule.And(left, right) =>
missingRequirements(left, person) ++ missingRequirements(right, person)
case Rule.Or(left, right) => {
val leftMissing = missingRequirements(left, person)
val rightMissing = missingRequirements(right, person)
if leftMissing.isEmpty || rightMissing.isEmpty
then Nil
else leftMissing ++ rightMissing
}
}
missingRequirements(getsLoanRule(100000.0), marco)
// => List(Income >= 60000.0)
missingRequirements(getsLoanRule(100000.0), mimi)
// => List(Age >= 23, Employed, Income >= 45000.0, Equity >= 25000.0, ...)
Ohne an der Definition der Regeln etwas zu verändern, ergibt sich sofort – durch eine neue Interpretation der Daten – die Antwort auf die Frage. Möchten Sie die Regeln als Liste von Bedingungen haben? Kein Problem (Listing 8).
Listing 8
def describe[A](rule: Rule[A]): String = rule match {
case Predicate(name, _) => name
case And(left, right) =>
"(" + left.describe() + " UND " + right.describe() + ")"
case Or(left, right) =>
"(" + left.describe() + " ODER " + right.describe() + ")"
}
describe(getsLoanRule(200000.0))
// => ((Age >= 23 Jahre AND Employed)
// UND ((Income >= 45000.0 UND Income >= 50000.0)
// ODER (Income >= 60000.0 UND Equity >= 20000.0)))
Wir müssen dafür nicht einmal eine konkrete Person übergeben – das alles leitet sich nur aus der Deklaration der Regeln ab. Das ist auch eine weitere Erkenntnis: Regeln haben erst einmal nichts mit Personen zu tun. Manche Eigenschaften leiten sich auch direkt aus ihrer Definition ab.
Erweiterbarkeit
Das Hinzufügen neuer Regeln oder Logiken ist ebenfalls einfach. Zum Beispiel können wir eine Regel für Personen unter 21 Jahren ohne Änderung an der bestehenden Logik hinzufügen:
def youthRule: Rule[Person] = Rule.Predicate("Under 21", _.age < 21)
Lesbarkeit und Wartbarkeit
Jede Regel ist klar und in sich geschlossen und dadurch auch näher an der Problemstellung, als es der Fall ist, wenn wir ad hoc Funktionen oder Methoden definieren. Regeln sind deklarativ und leicht nachvollziehbar.
Vorteile von Scala
Scala ist besonders gut für diesen Ansatz geeignet. Seitdem in Scala Enums als native Unterstützung zur Implementierung algebraischer Datentypen hinzukamen, ist die Modellierung so einfach wie nie. Sie erweitern die bekannten Case-class-Konstrukte um kompakte, algebraische Datentypen. Das Pattern Matching ermöglicht dann eine intuitive und kompakte Verarbeitung unserer Regeln und erinnert uns, falls wir einmal etwas vergessen sollten. Dabei hilft auch die starke Typisierung. Im Vergleich zu Java bietet Scala eine elegantere Syntax und native Unterstützung für funktionale Programmierung, wodurch der deklarative Ansatz einfacher umzusetzen ist.
Die Mathematik dahinter
Das Prinzip der Kombinatormodelle kommt aus der Mathematik. In der Mathematik, insbesondere dem Themengebiet Algebra, untersucht man Mengen von Objekten und Strukturen auf ihnen. Die Entsprechung unserer Kreditdomänenmodellierung, also dass zwei Regeln zusammengefügt wieder eine Regel ergeben, ist dort eine sogenannte Halbgruppe (M, ∘), bestehend aus einer Menge M und einer assoziativen, zweistelligen inneren Verknüpfung ∘:
∘ : M x M -> M
Assoziativ heißt, dass folgende Gleichung für alle x, y und z aus M gilt:
(x ∘ y) ∘ z = x ∘ (y ∘ z)
Was bringt uns diese Entsprechung und der Bezug zur Mathematik? Dadurch erhalten wir Klarheit über das, worüber wir sprechen, und wir können Resultate und Erkenntnisse über Halbgruppen auf unser Domänenmodell übertragen.
Im Folgenden betrachten wir And als Halbgruppe (mit Or geht das analog). Was ist nützlich an einer Halbgruppe?
-
Abgeschlossenheit: Wir wissen, dass je zwei verknüpfte Elemente wieder ein Element der Menge ergeben.
-
Assoziativität: Wir können mehrere Verknüpfungen beliebig klammern (dass Assoziativität bei unserer Domäne gegeben ist, ist noch zu erörtern).
Der Vorteil der Abgeschlossenheit liegt auf der Hand und wurde oben bereits thematisiert. Aber was gibt uns die Erkenntnis über die Assoziativität? Zunächst einmal ist diese im vorliegenden Fall abhängig von der Interpretation. Im interessanten Fall von evaluate ist sie gegeben, weil sie auf boolescher Logik aufbaut.
Nun dazu, warum sie nützlich ist: wenn wir beliebig klammern können, können wir beispielweise auch verschachtelte And-Ausdrücke beliebig klammern und nebeneinander via evaluate auswerten. Ein Wert, der zunächst links geklammert ist:
And(And(And(And(And(aRule,bRule),cRule),dRule),eRule),fRule)
könnte umgeklammert werden zu
And(And(aRule,And(bRule,cRule)),And(dRule,And(eRule,fRule)))
Damit könnten wir die beiden „Teil-Ands“
And(aRule,And(bRule,cRule))
And(dRule,And(eRule,fRule))
unabhängig voneinander auswerten und anschließend mit & verknüpfen. Das heißt, bei gegebener Assoziativität kann direkt von paralleler Ausführung profitiert werden.
Unsere Struktur kann noch mächtiger werden. Wozu das u. a. gut ist, zeigt eine kleine Erweiterung, die es uns komfortabel ermöglicht, mehrere Regeln mit UND zu verknüpfen:
def manyAnds(Seq[Rule[A]]): Rule[A] = ???
Bei der Implementierung von manyAnds wird auffallen, dass es vonnöten ist, eine „leere Regel“ zu haben, die das UND-Verknüpfen folgendermaßen unterstützt:
And(rule, emptyRule) = rule
And(emptyRule, rule) = rule
Eine mögliche elegante Implementierung geschieht via fold:
def manyAnds(rules: Seq[Rule[Person]]): Rule[Person] =
rules.fold(EmptyAnd())((rule, otherRule) => And(rule, otherRule))
Mit EmptyAnd stoßen wir auf eine weitere mathematische Struktur, das Monoid: Ein Monoid (M,∘,e) ist eine Halbgruppe mit einem neutralen Element e, das sich bei Verknüpfung mit jedem Element m aus M so verhält:
m ∘ e = m
e ∘ m = m
Zu was wertet unsere neue Regel EmptyAnd aus? Da es sich bei der Semantik unseres And um boolesche Logik handelt, liegt das auf der Hand: true! Man könnte meinen, dass EmptyAnd hier nur technischer Natur ist, sich vielleicht sogar darüber beschweren. Oft aber erkennt, gewinnt oder entdeckt man beim genauen Hinsehen sogar einen neuen Domänenteil! Hier beispielsweise einen „Wir-nehmen-jede:n“-Kredit mit besonders schlechten Konditionen.
Es gibt viele weitere mathematische Konzepte (z. B. Gruppen, Monaden, Funktoren) die wir in der funktionalen Programmierung zu unserem Vorteil nutzen; daher versuchen wir bereits bei oder nach der Modellierung der Domäne, so viel Struktur zu finden, wie es nur geht.
Um auch im Code auszudrücken, dass sich hier ein Monoid finden lässt, können wir die Library Cats [1] benutzen (Listing 9).
Listing 9
import cats.Monoid
given rulesAndMonoid: Monoid[Rule[Person]] with {
def empty: Rule[Person] = EmptyAnd()
def combine(x: Rule[Person], y: Rule[Person]): Rule[Person] =
And(x, y)
}
def combineAll[A: Monoid](as: List[A]): A =
as.foldLeft(Monoid[A].empty)(Monoid[A].combine)
Dadurch erübrigt sich zudem Schreibarbeit: Das einmalig geschriebene, allgemein auf Monoiden operierende combineAll ersetzt nun das spezielle manyAnds.
Fazit
Datenmodellierung mit Kombinatormodellen bietet eine überzeugende Alternative zu traditionellen Ansätzen in der Domänenmodellierung. Durch klare, modulare und deklarative Strukturen erleichtern sie die Erweiterung und Wartung des Codes erheblich. Mit Scala wird dieser Ansatz dank dessen moderner Sprachfeatures noch leistungsfähiger und intuitiver. Einfach mal ausprobieren – und sich überraschen lassen und darüber freuen, wie viel einfacher die Domänenmodellierung wird, wenn wir Kombinatormodelle benutzen.
Links & Literatur
[1] Typelevel Cats: https://typelevel.org/cats/